今天接續昨天的內容,要繼續實作註冊帳號時的 Email 驗證功能。
昨天我們已經梳理過流程,說明了要修改與新增的內容,並且也完成了前置作業(申請應用程式密碼與引入套件)
現在讓我們著手完成這個功能吧~
我們先到 AuthService
中新增呼叫 /register
與 /verify-email
端點的方法。再到 auth package 底下新增 register
與 verify-email
元件處理對應的畫面與邏輯。
(因為到目前為止都聚焦在後端的開發,前端頁面只是方便實作某些需要前後端配合的功能,因此針對前端程式碼的處理都會比較簡略,暫且忽略大部分檢核與錯誤處理。)
在 AuthService
中新增呼叫 /register
與 /verify-email
端點的方法:
export class AuthService {
...
// 呼叫後端 API 來註冊一個新使用者。
register(registrationData: RegistrationRequest): Observable<RegistrationResponse> {
const backendApi = `${environment.apiBaseUrl}/users/register`;
return this.http.post<RegistrationResponse>(backendApi, registrationData);
}
// 呼叫後端 API 來驗證 Email Token。
verifyEmail(token: string): Observable<VerificationResponse> {
const backendApi = `${environment.apiBaseUrl}/users/verify-email`;
return this.http.post<VerificationResponse>(backendApi, { token });
}
}
關於傳入參數:RegistrationRequest
根據我們目前後端的設計,註冊僅需 email 與密碼;驗證email 則僅需提供 token 給後端進行驗證。
透過 Reactive Form 來建立一個簡單的註冊表單。
register.html
註冊頁面包含 email 、密碼欄位與登入按鈕,此處透過 FormGroup 搭配 FormControl 來接收表單輸入值:
<div class="register-container">
<h1>註冊頁面</h1>
<form [formGroup]="registerForm" (ngSubmit)="onSubmit()">
<div class="form-group">
<label for="email">Email</label>
<input id="email" type="email" formControlName="email" >
</div>
<div class="form-group">
<label for="password">密碼</label>
<input id="password" type="password" formControlName="password" >
</div>
<button type="submit" [disabled]="registerForm.invalid">註冊</button>
</form>
</div>
register.ts
若是註冊成功,此處以 Alert 提示使用者需要到信箱啟用帳號,並將用戶導回登入頁:
export class RegisterComponent {
registerForm: FormGroup;
constructor(
private fb: FormBuilder,
private authService: AuthService,
private router: Router
) {
// 建立表單以接收輸入值
this.registerForm = this.fb.group({
email: ['', [Validators.required, Validators.email]],
password: ['', [Validators.required, Validators.minLength(8)]],
});
}
get email() {
return this.registerForm.get('email');
}
get password() {
return this.registerForm.get('password');
}
onSubmit(): void {
// 呼叫 AuthService 中的註冊方法
this.authService.register(this.registerForm.value).subscribe({
next: () => {
alert('註冊成功!請檢查您的信箱以啟用帳號。');
this.router.navigate(['/auth/login']);
}
});
}
建立一個簡單的驗證頁面如下:
verify-email.html
<div class="container">
<div *ngIf="status === 'verifying'">
<p>正在驗證您的 Email,請稍候...</p>
</div>
<div *ngIf="status === 'success'">
<h2>帳號啟用成功!</h2>
</div>
</div>
verify-email.ts
這個頁面的元件在初始化時 ,會從 URL 中解析出 token
參數,並向後端的 /verify-email
端點發起請求,並根據請求結果渲染頁面:
export class VerifyEmailComponent implements OnInit {
status: 'verifying' | 'success' = 'verifying';
constructor(
private route: ActivatedRoute,
private router: Router,
private authService: AuthService
) { }
ngOnInit(): void {
const token = this.route.snapshot.queryParamMap.get('token');
if (token) {
this.authService.verifyEmail(token).subscribe({
next: () => {
this.status = 'success';
}
// TODO:未來根據後端回傳進行錯誤處理,若 token 過期要可以重發驗證信。
});
} else {
this.router.navigate(['/auth/login']);
}
}
}
這個章節將依序說明以下實作:
verify-email
方法/verify-email
端點。VerificationToken Entity
預期 VerificationToken
應包含 token
字串、UserEntity
的關聯 (外鍵),以及一個 expiryDate
(過期時間)。
@Entity
@Getter
@Setter
@NoArgsConstructor
public class VerificationTokenEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private String token;
// 一個 Token 只對應一個使用者
@OneToOne(fetch = FetchType.LAZY)
@JoinColumn(nullable = false, name = "user_id")
private UserEntity user;
@Column(nullable = false)
private Instant expiryDate;
public boolean isExpired() {
return this.expiryDate.isBefore(Instant.now());
}
public VerificationTokenEntity(String token, UserEntity user, Instant expiryDate) {
this.token = token;
this.user = user;
this.expiryDate = expiryDate;
}
}
VerificationTokenRepository
新增 VerificationTokenEntity
的 Repository 好讓我們可以在資料庫中新增資料,新增 findByToken
方法以供後續驗證 Email 的 API 使用:
public interface VerificationTokenRepository extends JpaRepository<VerificationTokenEntity, Long> {
Optional<VerificationTokenEntity> findByToken(String token);
}
建立一個新的 Service,當中處理包含與 Email 相關的服務。
EmailServiceImpl
新增 sendVerificationEmail
方法來實作寄送驗證 Email 的方法:
@Service
public class EmailServiceImpl implements EmailService {
private static final Logger logger = LoggerFactory.getLogger(EmailService.class);
@Autowired
private JavaMailSender mailSender;
@Value("${frontend.base.url}")
private String frontendBaseUrl;
@Async // 標記此方法為非同步執行
public void sendVerificationEmail(UserEntity user, String token) {
try {
// 建立郵件內容
SimpleMailMessage email = new SimpleMailMessage();
email.setFrom("Food Print Service<xxxx@gmail.com>"); // 指定寄件人名稱
email.setTo(user.getEmail());
email.setSubject(" 【Food Print Service】請驗證您的 Email 以啟用帳號");
String message = buildVerificationMessage(user, token);
email.setText(message);
// 寄送郵件
mailSender.send(email);
logger.info("已成功寄送驗證信至:{}", user.getEmail());
} catch (MailException e) {
// 非正式的錯誤處理
logger.error("寄送驗證信至 {} 時發生錯誤", user.getEmail(), e);
}
}
private String buildVerificationMessage(UserEntity user, String verificationToken) {
String confirmationUrl = frontendBaseUrl + "/auth/verify-email?token=" + verificationToken;
return String.format(
"您好,\n\n" +
"感謝您註冊 Foot Print Service,請點選下方連結啟用您的帳戶:\n\n" +
"%s\n\n" +
"若未於本網站註冊,請忽略此信。",
confirmationUrl
);
}
}
UserEntity
最初我們實作 UserDetails
,簡單讓 isEnabled
方法固定回傳 true。現在讓我們加入 enabled 欄位,讓相關服務可以透過 setter 來改變帳號的驗證狀態:
public class UserEntity implements UserDetails {
...
@Column(nullable = false)
private boolean enabled = false;
@Override
public boolean isEnabled() { return this.enabled; }
}
register
回到 AuthService
,在先前開發的 register
方法中新增兩個環節:
(1) 新建用戶時將驗證狀態設為停用
(2) 建立驗證 Token ,並加入寄發驗證連結。
@Transactional
public UserEntity registerUser(RegistrationRequest registrationRequest){
if (userRepository.findByEmail(registrationRequest.email()).isPresent()) {
throw new IllegalStateException("該信箱已被註冊。");
}
UserEntity user = new UserEntity();
user.setEmail(registrationRequest.email());
user.setPassword(passwordEncoder.encode(registrationRequest.password()));
// (1)建立用戶時將驗證狀態設為停用
user.setEnabled(false);
UserEntity savedUser = userRepository.save(user);
// (2)建立驗證 Token 並呼叫非同步方法寄送驗證 Email
String token = UUID.randomUUID().toString();
VerificationTokenEntity verificationToken = new VerificationTokenEntity(token, user, Instant.now().plusMillis(refreshTokenExpirationMs));
verificationTokenRepository.save(verificationToken);
emailService.sendVerificationEmail(user, token);
return savedUser;
}
verifyEmail
在 AuthService
中新增 verifyEmail
的方法驗證 token 有效性:
public void verifyEmail(String token) {
// 1. findByToken
VerificationTokenEntity verificationToken = verificationTokenRepository.findByToken(token)
.orElseThrow(() -> new TokenNotFoundException("無效的驗證 Token"));
// 2. 檢查 Token 是否過期
if (verificationToken.isExpired()) {
verificationTokenRepository.delete(verificationToken);
throw new TokenExpiredException("此驗證 Token 已過期");
}
// 3. 使用者狀態設定為啟用
UserEntity user = verificationToken.getUser();
user.setEnabled(true);
userRepository.save(user);
// 4. 刪除已使用的 Token
verificationTokenRepository.delete(verificationToken);
}
此處自訂了 Exception,並在 GlobalExceptionHandler 定義例外處理,前面實作有過類似的說明就不附上內容了。
AuthController
我們開立 /verify-email
端點提供前端驗證 Eaill 使用:
public class AuthController {
...
@PostMapping("/verify-email")
public ResponseEntity<?> verifyEmail(@Valid @RequestBody VerificationRequest verificationRequest) {
authService.verifyEmail(verificationRequest.token());
return ResponseEntity.ok().build();
}
}
在此簡單附上功能測試成功的幾個主要畫面。
於註冊頁面註冊成功後,提醒用戶至信箱啟用帳戶。
至信箱查看寄發之驗證信內容。
點擊信箱內的驗證連結,導回驗證頁面並顯示驗證結果。
透過今天簡單的實作,我們完成了在建立帳號時寄送驗證 Email 的服務。
不過既然我們設計 token 都有考量到過期的狀況,就表示應該有個重發驗證信的機制,今天的內容本來有包含重新寄發驗證信的功能,但考量到篇幅與對應的處理會使整篇文章更冗贅,還是先行移除了,著重在實作 Email 驗證功能上。